#!/usr/bin/python3
# -*- coding: utf-8; tab-width: 4; indent-tabs-mode: t -*-

import os
import sys
import time
import json
import dbus
import stat
import struct
import shutil
import psutil
import socket
import sqlglot
import hashlib
import asyncio
import importlib
import traceback
import subprocess
from gi.repository import GLib


class PdcUtil:

    @staticmethod
    def asyncProcTerminate(proc):
        try:
            proc.terminate()
        except ProcessLookupError:
            # it seems asyncio has this exception, subprocess.Popen never raises this
            pass

    @staticmethod
    async def asyncSleepForever():
        # it is astonishing that there's no build-in function for this, sucks
        while True:
            await asyncio.sleep(3600)

    @classmethod
    def asyncSafeTaskCreate(cls, loop, coro, taskName=None, errPrefix=None, bNoCancel=False, bTerminateOnError=False, bTerminateOnEnd=False):
        if bTerminateOnEnd:
            assert bTerminateOnError
        ret = loop.create_task(cls._safeTaskMain(coro, errPrefix), name=taskName)
        ret._pdcUtilNoCancel = bNoCancel
        ret._pdcUtilTerminateOnError = bTerminateOnError
        ret._pdcUtilTerminateOnEnd = bTerminateOnEnd
        return ret

    @classmethod
    def asyncSafeTaskSetNoCancel(cls, task, bNoCancel):
        task._pdcUtilNoCancel = bNoCancel

    @classmethod
    def asyncSafeTaskSetTerminateOnError(cls, task, bTerminateOnError):
        if not bTerminateOnError:
            assert not task._pdcUtilTerminateOnEnd
        task._pdcUtilTerminateOnError = bTerminateOnError

    @classmethod
    def asyncSafeTaskSetTerminateOnEnd(cls, task, bTerminateOnEnd):
        if bTerminateOnEnd:
            assert task._pdcUtilTerminateOnError
        task._pdcUtilTerminateOnEnd = bTerminateOnEnd

    @staticmethod
    async def _safeTaskMain(coro, errPrefix):
        try:
            try:
                await coro
                if asyncio.current_task()._pdcUtilTerminateOnEnd:
                    print("Task %s ends unexpectedly." % (asyncio.current_task().name))
                    # terminate program by stopping the event loop
                    loop = asyncio.get_running_loop()
                    loop.call_soon(loop.stop)
            except asyncio.CancelledError:
                assert not asyncio.current_task()._pdcUtilNoCancel
                raise
            finally:
                # no task should be cancelled twice
                assert asyncio.current_task().cancelling() <= 1
        except asyncio.CancelledError:
            # can-be-cancelled task must be awaited by others, so we can safely throw this exception out
            raise
        except BaseException as e:
            if asyncio.current_task()._pdcUtilTerminateOnError:
                # simulate standard exception and stack traceback print out
                print("Traceback (most recent call last):")
                traceback.print_tb(e.__traceback__)
                print("%s: %s" % (e.__class__.__name__, f"{errPrefix}, {e}" if errPrefix is not None else f"{e}"))
                # terminate program by stopping the event loop
                loop = asyncio.get_running_loop()
                loop.call_soon(loop.stop)
            else:
                raise

    @staticmethod
    def getExistFile(filename):
        assert os.path.exists(filename)
        return filename

    @staticmethod
    def findSocketFileInDir(dirpath):
        for fn in os.listdir(dirpath):
            fullfn = os.path.join(dirpath, fn)
            if stat.S_ISSOCK(os.stat(fullfn).st_mode):
                return fullfn
        assert False

    @staticmethod
    def rotateLogCmdList(baseFilename, maxBytes, backupCount):
        return ["/usr/sbin/rotatelogs", "-f", "-n", str(backupCount), baseFilename, str(maxBytes) + "B"]

    @staticmethod
    def createRotateLogProcess(baseFilename, maxBytes, backupCount, outf):
        # rotate firstly
        for i in range(backupCount - 1, 0, -1):
            sfn = "%s.%d" % (baseFilename, i)
            dfn = "%s.%d" % (baseFilename, i + 1)
            if os.path.exists(dfn):
                os.remove(dfn)
            if os.path.exists(sfn):
                os.rename(sfn, dfn)
        dfn = baseFilename + ".1"
        if os.path.exists(dfn):
            os.remove(dfn)
        if os.path.exists(baseFilename):
            os.rename(baseFilename, dfn)

        # create process
        return subprocess.Popen(["/usr/sbin/rotatelogs", "-f", "-n", str(backupCount), baseFilename, str(maxBytes) + "B"], stdin=outf)

    @staticmethod
    def loadPythonFile(filename):
        # returns (mod-name,mod-object)
        modname = os.path.basename(filename).replace(".py", "").replace("-", "_")
        spec = importlib.util.spec_from_file(modname, filename)
        mod = importlib.util.module_from_spec(spec)
        spec.loader.exec_module(mod)
        return (modname, mod)

    @staticmethod
    def stdTmFmt():
        return "%Y-%m-%d %H:%M:%S"

    @staticmethod
    def sqlInsertStatement(tableName, valueDict):
        return "INSERT INTO " + tableName + " (" + ",".join(valueDict.keys()) + ") VALUES ('" + "','".join(valueDict.values()) + "');"

    @staticmethod
    def mysqlPrivJson(password=None):
        if password is None:
            return '{"access":0}'
        else:
            # password_last_changed must be >0, or else mysql would force the user change his/her password
            return '{"access":0,"plugin":"mysql_native_password","authentication_string":"%s","password_last_changed":1}' % (PdcUtil.mysqlPasswordHash(password))

    @staticmethod
    def mysqlPasswordHash(password):
        r = hashlib.sha1(password.encode("iso8859-1")).digest()
        r = hashlib.sha1(r).hexdigest()
        return '*' + r

    @staticmethod
    def readFile(filename, defaultContent=None):
        if not os.path.exists(filename) and defaultContent is not None:
            return defaultContent
        with open(filename) as f:
            return f.read()

    @staticmethod
    def rreplace(s, sub, dst, count):
        # https://stackoverflow.com/questions/9943504/right-to-left-string-replace-in-python
        return dst.join(s.rsplit(sub, count))

    @staticmethod
    def cmdCall(cmd, *kargs):
        # call command to execute backstage job
        #
        # scenario 1, process group receives SIGTERM, SIGINT and SIGHUP:
        #   * callee must auto-terminate, and cause no side-effect
        #   * caller must be terminated by signal, not by detecting child-process failure
        # scenario 2, caller receives SIGTERM, SIGINT, SIGHUP:
        #   * caller is terminated by signal, and NOT notify callee
        #   * callee must auto-terminate, and cause no side-effect, after caller is terminated
        # scenario 3, callee receives SIGTERM, SIGINT, SIGHUP:
        #   * caller detects child-process failure and do appopriate treatment

        ret = subprocess.run([cmd] + list(kargs),
                             stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
                             universal_newlines=True)
        if ret.returncode > 128:
            # for scenario 1, caller's signal handler has the oppotunity to get executed during sleep
            time.sleep(1.0)
        if ret.returncode != 0:
            print(ret.stdout)
            ret.check_returncode()
        return ret.stdout.rstrip()

    @staticmethod
    def cmdCallWithInput(cmd, inStr, *kargs):
        ret = subprocess.run([cmd] + list(kargs),
                             stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
                             input=inStr, universal_newlines=True)
        if ret.returncode > 128:
            time.sleep(1.0)
        if ret.returncode != 0:
            print(ret.stdout)
            ret.check_returncode()
        return ret.stdout.rstrip()

    @staticmethod
    def preparePersistDir(dirname, uid, gid, mode):
        if not os.path.exists(dirname):
            os.makedirs(dirname, mode)
            if os.getuid() != uid or os.getpid() != gid:
                os.chown(dirname, uid, gid)
        else:
            st = os.stat(dirname)
            if stat.S_IMODE(st.st_mode) != mode:
                os.chmod(dirname, mode)
            if st.st_uid != uid or st.st_gid != gid:
                os.chown(dirname, uid, gid)
                for root, dirs, files in os.walk(dirname):
                    for d in dirs:
                        os.lchown(os.path.join(root, d), uid, gid)
                    for f in files:
                        os.lchown(os.path.join(root, f), uid, gid)

    @staticmethod
    def prepareTransientDir(dirname, uid, gid, mode):
        PdcUtil.forceDelete(dirname)
        os.makedirs(dirname, mode)
        if os.getuid() != uid or os.getpid() != gid:
            os.chown(dirname, uid, gid)

    @staticmethod
    def getUnixDomainSocketPeerInfo(sock):
        # returns (pid, uid, gid)
        pattern = "=iii"
        length = struct.calcsize(pattern)
        ret = sock.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, length)
        return struct.unpack(pattern, ret)

    @staticmethod
    def forceDelete(filename):
        if os.path.islink(filename):
            os.remove(filename)
        elif os.path.isfile(filename):
            os.remove(filename)
        elif os.path.isdir(filename):
            shutil.rmtree(filename)

    @staticmethod
    def mkDirAndClear(dirname):
        PdcUtil.forceDelete(dirname)
        os.mkdir(dirname)

    @staticmethod
    def getFreeSocketPort(portType):
        if portType == "tcp":
            stlist = [socket.SOCK_STREAM]
        elif portType == "udp":
            stlist = [socket.SOCK_DGRAM]
        elif portType == "tcp+udp":
            stlist = [socket.SOCK_STREAM, socket.SOCK_DGRAM]
        else:
            assert False

        for port in range(10000, 65536):
            bFound = True
            for sType in stlist:
                s = socket.socket(socket.AF_INET, sType)
                try:
                    s.bind((('', port)))
                except socket.error:
                    bFound = False
                finally:
                    s.close()
            if bFound:
                return port

        raise Exception("no valid port")

    @staticmethod
    def waitSocketPortForProc(portType, ip, port, proc, timeout=10):
        assert portType in ["tcp", "udp"]
        for i in range(0, timeout*10):
            if proc.poll() is not None:
                raise Exception("process terminated")
            time.sleep(0.1)
            for c in psutil.net_connections(kind=portType):
                # if c.pid == proc.pid and c.status == "LISTEN":        # FIXME: for apache, c.pid is wrong, result of netstat is right
                if c.status == "LISTEN":
                    if c.family == socket.AF_INET and c.laddr[0] == ip and c.laddr[1] == port:
                        return
                    if ip == "0.0.0.0" and c.family == socket.AF_INET6 and c.laddr[0] == "::" and c.laddr[1] == port:
                        return
        raise Exception("timeout")

    @staticmethod
    def touchFile(filename):
        if not os.path.exists(filename):
            with open(filename, 'w'):
                pass

    @staticmethod
    def getSyscallNumber(syscallName):
        # syscallName example: "SYS_prctl"
        out = PdcUtil.cmdCallWithInput("/usr/bin/gcc", "#include <sys/syscall.h>\n%s" % (syscallName), "-E", "-")
        syscall_number = out.split("\n")[-1]
        try:
            syscall_number = int(syscall_number)
        except ValueError:
            raise Exception("failed to get syscall number for %s" % (syscallName))
        if not 0 <= syscall_number <= 999:
            raise Exception("failed to get syscall number for %s" % (syscallName))
        return syscall_number

    @staticmethod
    def sqliteHasTables(dbConn, *tableNames):
        for tableName in tableNames:
            c = dbConn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name=?", (tableName,))
            if c.fetchone() is None:
                return False
        return True

    @staticmethod
    def sqliteCheckTables(dbConn, sql):
        for item in sqlglot.parse(sql):
            # FIXME
            pass

        # FIXME: should do extensively check
        for m in re.finditer(r"CREATE TABLE (.*) \(", sql):
            tableName = m.group(1)
            c = dbConn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name=?", (tableName,))
            if c.fetchone() is None:
                return False
        return True



class StdoutRedirector:

    def __init__(self, filename):
        self.terminal = sys.stdout
        self.log = open(filename, "a")

    def write(self, message):
        self.terminal.write(message)
        self.log.write(message)
        self.log.flush()

    def flush(self):
        self.terminal.flush()
        self.log.flush()

    def isatty(self):
        return self.terminal.isatty()


class DynObject:
    # an object that can contain abitrary dynamically created properties and methods
    pass


class UnixDomainSocketApiServer:

    def __init__(self, serverFile, clientAppearFunc, clientDisappearFunc, notifyFunc):
        # Parameter clientAppearFunc is called after client appears.
        # Parameter clientDisappearFunc is called after we find client disappears and before we destroy the client object.
        # Parameter clientDisappearFunc can be None.

        assert serverFile is not None
        assert clientAppearFunc is not None and notifyFunc is not None

        self.clientAppearFunc = clientAppearFunc
        self.clientDisappearFunc = clientDisappearFunc
        self.notifyFunc = notifyFunc

        self.serverSock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
        self.serverSock.bind(serverFile)
        self.serverSock.listen(5)
        self.serverSourceId = GLib.io_add_watch(self.serverSock, GLib.IO_IN, self.onServerAccept)

        self.clientInfoDict = dict()

    def dispose(self):
        for sock, obj in self.clientInfoDict.items():
            GLib.source_remove(obj.inWatch)
            sock.close()
        GLib.source_remove(self.serverSourceId)
        self.serverSock.close()

    def onServerAccept(self, source, cb_condition):
        # event callback, no exception is allowed

        new_sock, addr = source.accept()

        try:
            data = self.clientAppearFunc(new_sock)
        except BaseException:
            # absorb exception raised by upper layer function
            traceback.print_exc()
            new_sock.close()
            return True

        obj = DynObject()
        obj.inWatch = GLib.io_add_watch(new_sock, GLib.IO_IN | GLib.IO_PRI | GLib.IO_ERR | GLib.IO_HUP | GLib.IO_NVAL, self.onRecv)
        obj.recvBuf = b''
        obj.clientData = data
        self.clientInfoDict[new_sock] = obj
        return True

    def onRecv(self, source, cb_condition):
        bCloseSocket = False
        try:
            obj = self.clientInfoDict[source]

            # receive and parse
            obj.recvBuf += source.recv(4096)
            while True:
                i = obj.recvBuf.find(b'\n')
                if i < 0:
                    break
                jsonObj = json.loads(obj.recvBuf[:i].decode("utf-8"))
                obj.recvBuf = obj.recvBuf[i + 1:]
                try:
                    self.notifyFunc(obj.clientData, jsonObj)
                except BaseException:
                    # absorb exception raised by upper layer function, FIXME
                    print("upper layer exception")
                    traceback.print_exc()
                    raise

            # remote closed
            if (cb_condition & GLib.IO_HUP):
                bCloseSocket = True
                if len(obj.recvBuf) > 0:
                    raise Exception("remote close")
        except BaseException:
            print("excp IO_IN, %d" % (cb_condition & GLib.IO_IN))               # FIXME
            print("excp IO_PRI, %d" % (cb_condition & GLib.IO_PRI))
            print("excp IO_ERR, %d" % (cb_condition & GLib.IO_ERR))
            print("excp IO_HUP, %d" % (cb_condition & GLib.IO_HUP))
            print("excp IO_NVAL, %d" % (cb_condition & GLib.IO_NVAL))
            traceback.print_exc()
        finally:
            if bCloseSocket:
                if self.clientDisappearFunc is not None:
                    self.clientDisappearFunc(self.clientInfoDict[source].clientData)
                del self.clientInfoDict[source]
                source.close()
                return False
            else:
                return True


class BtLeAdvertisementMonitor:

    def __init__(self, prefix, dbusPath, deviceFoundCallback):
        self._myPath = os.path.join("/", prefix, dbusPath)
        self._deviceFoundCallback = deviceFoundCallback

        self._monitorDict = None
        self._bMonitorCreated = False

        try:
            bus = dbus.SystemBus()
            bus.register_object(self._myPath, self, None)

            bluezObj = bus.get_object("org.bluez", "/")
            bluezManager = dbus.Interface(bluezObj, "org.freedesktop.DBus.ObjectManager")
            for path, interfaces in bluezManager.GetManagedObjects().items():
                if "org.bluez.Adapter1" in interfaces:
                    self._monitorDict[path] = dbus.Interface(bus.get_object("org.bluez", path), "org.bluez.LEAdvertisingManager")
                    self._monitorDict[path].RegisterAdvertisement(self._myPath, {
                        "Type": "passive",
                        # "RSSIThreshold": -70,  # Optional: filter by signal strength
                        # "UUIDs": ["0000180f-0000-1000-8000-00805f9b34fb"],  # Optional: filter by UUIDs
                    })
                    self._bMonitorCreated = True
            
            # If no adapters found, raise exception
            if not self._bMonitorCreated:
                raise Exception("No Bluetooth adapters found")
        except BaseException:
            self.dispose()
            raise

    def dispose(self):
        bus = dbus.SystemBus()
        if self._bMonitorCreated:
            self._monitorDict.UnregisterAdvertisement(self._myPath)
        bus.unregister_object(self._myPath)

    @dbus.service.method("org.bluez.LEAdvertisingMonitor1", in_signature='a{sv}', out_signature='')
    def DeviceFound(self, properties):
        self._deviceFoundCallback()

        # address = properties.get("Address", "Unknown")
        # rssi = properties.get("RSSI", "Unknown")
        # name = properties.get("Name", "Unknown")
        # print(f"Device found:")
        # print(f"  Address: {address}")
        # print(f"  Name: {name}")
        # print(f"  RSSI: {rssi} dBm")
        # print("------------------------")

    @dbus.service.method("org.bluez.LEAdvertisingMonitor1", in_signature='a{sv}', out_signature='')
    def DeviceUpdated(self, properties):
        address = properties.get("Address", "Unknown")
        print(f"Device updated: {address}")

    @dbus.service.method("org.bluez.LEAdvertisingMonitor1", in_signature='s', out_signature='')
    def DeviceLost(self, address):
        print(f"Device lost: {address}")
